Building on Mars Part I - Interactive 3D City + THREE.js

javascripts
Author

F.L

Published

October 22, 2024

Create 3D city map and put it on Mars In this 3D map, you can hover over an 3D object, and “magic” will happen: which is letting you


Overview

Step 1: Generate a 3D map Step 2: Load these asset using two pre-written example function from library THREE NOTE: You need identify those individual element; Step 3: Set up raycaster and normalized mouse casting;

Step 0: Having a Node Enrionment

First you need already have “node.js” and “vite” set up in your directory and having an index.html and a script in your directory (Install Three.js).

├── index.html
├── script.js

In script.js

// script.js
// a1-3D-City-Map.html
import * as THREE from 'three';
// STL loader
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
// For camera view
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'

// if loading a project or Gltf use this
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
// ...

Add this script to html anywhere as a module

<body>
  <script type="module" src="a1-3D-City-Map.js"></script>
</body> 

To start serving this three-D scene, just go

# in your project terminal go
npx vite

Step 1: Generate Fake 3D City Map

Utilize open source software to generate a FAKE map by probabletrain: Probabletrain’s 3D Map Generator This will give you a directory looks like this:

public/model 2
├── README.txt
├── blocks.stl
├── buildings.stl
├── coastline.stl
├── domain.stl
├── river.stl
├── roads.stl
└── sea.stl

The whole building part. What I do is turn the bundle file into parts using blender for advanced editing.

public/model 2/building-parts
├── building.bin
├── building.gltf
├── individuals.bin
├── individuals.glb
├── individuals.gltf
├── part.bin
├── part.gltf
└── parts.stl

Step 2: Loading: Two Different Loading

When exporting from Blender there are two 3D object format, one is .stl, the other is .gltf. They seem to be written for a few examples for THREE.js, but so useful that we may as well just use them here.

Single STL is a single gometry file

  • Within each stl is a single “geoemtry” object (vector specifying the shape of geometry)
    • Geometry is just a skeleton, now we want to add skins, known as materials [[/Building and Road Materials]]
    • The magic fomula is Matrial + Geometry = Mesh!
    let road = null;
    loader.load(
      '/model 2/roads.stl',// 1** the first arg is stl file location
      function (geometry) {
          // **2.1 create a mesh using stil geometry
          const mesh = new THREE.Mesh(geometry, road_material);
          road = mesh; // **2.2 expose this object so we can reference later
          road.obj_type = "road" // set a custom attribute for reference
          scene.add(mesh); // 2.3 add this mesh to scene
      },  // 2** the second tell THREE to add scene
      (xhr) => {
          console.log((xhr.loaded / xhr.total) * 100 + '% loaded')
      },  // 3** the thrid is side effect or midware?
      (error) => {
          console.log(error)
      }   // 4** error handle
    )

    GLTF is like a collection of gomemetry mesh material, animation exported out of blender

  • GLTF has attribute “scene”
let buildings = null
gltf_loader.load(
    "/model 2/building-parts/building.gltf",
    function(gltf) {
        // because out of blender the rotating is wrong
        gltf.scene.rotation.x = Math.PI / 2;
        // this is label not copy
        buildings = gltf.scene.children
        // console.log(buildings)
        buildings.map(mesh=> mesh.material=building_material.clone()) // clone is actually important, if you don't clone materials, the materials will reference the same thing, so when you change material color the whole thing changes
        buildings.map(mesh=>mesh.obj_type = "building")
        buildings.map((mesh,index)=>mesh.obj_id = index)
        // this modify the whole thing in the mesh
        scene.add(gltf.scene)
    }
)

Step 3: Set up orbit control and ray cast!

Setup Raycaster

In a 2D space, your cursor’s x & y can be used to indicate which element on a 2D plan is selected, in a 3D space however, you need projection. For your 2D computer screen to “know” which 3D object is selected, you need imformation about viewing angle - this is your camera. * mouse: you need track cursor x and y. If you have event listener, this is “clientX” & “clientY” (W3School teach you basic javascript: W3Schools online HTML editor). This is expressed relative to client screen size. For three to use they need to be normalized * raycaster : think of raycaster as a red layser beam pointing at any 3D blocks. Finally, to find what object are calculated were “penetrated” through the “raycaster” you use this method from “raycaster” object called “setFromcamera”.

raycaster.setFromCamera(mouse, camera);

In context of THREE scene:

// Function to detect the object under the mouse and change its color
window.addEventListener('mousemove', onMouseMove, false);
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
function onMouseMove(event) {
  // Calculate mouse position in normalized device coordinates (-1 to +1) for both components
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
};

Control Color Change on Element Selected

raycaster have a property for storing any object the “ray” penetrates. This vector store a tremendours amout of json property. The one we want is the one called “object”. We want whatever individual building block is selected, the material color needs changing.

// change color of selected geometry
function hoverInteraction() {
    // Update the raycaster with the current camera and mouse positions
    raycaster.setFromCamera(mouse, camera);
  
    // Calculate objects intersecting the raycaster
    const intersects = raycaster.intersectObjects(buildings,false);
    // If there's an intersected object, change its color
    // if(building) {
    if(intersects.length != 0) {
        const intersectedObject = intersects[0].object;
        if(!!intersectedObject.obj_type) {
            if(intersectedObject.obj_type == "building") {
       // dirty way to change only selected building color only
                intersectedObject.material.color.set("#3498db")
        // if we want to be fancy, we can also change material here, something more glossy
            }
        } 
    } else {
        buildings.map(mesh=> mesh.material.color.set("#f7f9f9"))
    }
  }

Eventually the renderer function (onMonseMove will go on to animate and render) #### Camera Control & Animate Animate by convension may not make sense for us if we are just rotating a static 3D object. But in fact the concept “Animate” is important in implementing 3D interactivity to web development. Its is intuitive for web user to think they are interacting with a virtual object, when in fact what they are interacting with is a “two dimension plane” and “videos”, or “steaming frames” the webserver sent every second for your eyes only.

So the task of rotating viewing angle of the object is infact, tracking how my click movement has moved and rotate my camera based on it.

Fortunately someone have already wrote this task for us.

const controls = new OrbitControls(camera, renderer.domElement) //renderer is webGL render
controls.enableDamping = true

Finally, we setup streaming for the object to animate:

function render() {
    renderer.render(scene, camera)
}
function animate() {
    requestAnimationFrame(animate);
    hoverInteraction(); // just defined in previous step
    controls.update();
    render()
}
animate()

Travia

Here are the code you will need when building ### THREE.js scene & View #### Setup a 3D scene in THREE.js

// script.js
// ... import library ...
// setup a scene
const scene = new THREE.Scene()
scene.background = new THREE.Color("#85929e")
scene.backgroundBlurriness = 0.5

// lightings, no light some of your material will not work
const directionalLight = new THREE.DirectionalLight("#aed6f1",10)
directionalLight.position.z = 4
directionalLight.position.y = -15

// shiny another lighting
const directionalLight2 = new THREE.DirectionalLight("#fef9e7",5)
directionalLight2.position.z = -1
directionalLight2.position.y = 10
scene.add(directionalLight,directionalLight2)

// you always need camera for any scene
const camera = new THREE.PerspectiveCamera(
    100,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
)

// move camera
camera.position.z = 4
camera.position.y = -15

// render sence and add to html
const renderer = new THREE.WebGLRenderer()
renderer.setSize(window.innerWidth, window.innerHeight)

// append directly to you html document, rather than finding the corresponding id
document.body.appendChild(renderer.domElement)
renderer.setPixelRatio( window.devicePixelRatio );

What I use for Building and Road Materials

// script.js
// ... set up scene ...
const building_material = new THREE.MeshStandardMaterial({
    color: "#f7f9f9", roughness: 0, transparent: true, opacity: 0.85
})
const road_material = new THREE.MeshBasicMaterial({
    color: "#eaf2f8",  transparent: false
})

Reference